Skip to content

Fix and refactor UnderlineNav to resolve CLS issues and improve performance/code quality#7506

Open
iansan5653 wants to merge 71 commits intomainfrom
underline-nav-full-css-spike
Open

Fix and refactor UnderlineNav to resolve CLS issues and improve performance/code quality#7506
iansan5653 wants to merge 71 commits intomainfrom
underline-nav-full-css-spike

Conversation

@iansan5653
Copy link
Contributor

@iansan5653 iansan5653 commented Feb 6, 2026

This refact/bugfix PR reworks UnderlineNav's internal logic to rely mostly on CSS for calculating overflow, eliminating layout shift issues. In addition, it removes the use of the React.Children API in favor of context-based solutions, and replaces the custom overflow menu with an ActionMenu.

See https://github.com/github/primer/issues/6291#issuecomment-3861950186 for context.

The CSS overflow logic works like this:

  1. Overflowing menu items wrap onto subsequent lines, which are clipped by their container using overflow: hidden, creating the illusion that they disappeared
  2. The initial visibility of the overflow menu button and tab icons is controlled by scroll-state container queries to detect overflow using pure CSS
  3. Pure CSS cannot, however, tell us what to render inside the overflow menu. For this, each list item registers itself using context:
    1. The parent component provides register/unregister functions through context
    2. Each item component registers an IntersectionObserver to update itself with the parent when it appears/disappears. They determine whether they are overflowed by checking ref.current.offsetHeight (which will be greater than zero for an item that has wrapped). If they are overflowed, they set aria-hidden/tabIndex="-1" and notify the parent via the registry.
    3. The parent renders menu items for all currently-overflowing items in the overflow menu
    4. If any item has ever overflowed, all tab icons are suppressed via React state for the rest of the lifecycle of the component (this prevents flickering caused by the tab icons creating/removing space)

The benefits are pretty significant:

  1. The browser will calculate overflow for us before we ever even paint on the page - no waiting on a JS bundle or React hydration (this benefit is shared with the lighter-weight approach)
  2. Calculations are nearly pure CSS, so performance is buttery smooth for the whole component lifecycle
  3. We get to clean up tons of complex code, remove several effects, and remove hacky Children APIs entirely
  4. Bonus: Component consumption is much less fragile. Without Children APIs, we no longer need direct children, so consumers can wrap their underline items in fragments and even wrapper components

However, there are some caveats / user-facing changes:

  1. The most significant is that we can no longer ensure that the current item will not overflow into the menu. This is impossible to implement with the new approach; even if we were to extract the current item and render it outside the overflow menu, there's no place to put it since it would just wrap out of view if rendered into the list. If rendered next to the list, it would be pushed too far to the right due to the space caused by wrapping items in the list. Instead, I've added some styling to indicate the current item inside the menu:
    screenshot of overflow menu item with bold text and orange border line
  2. The overflow menu button must be right-aligned, as opposed to appearing right next to the last button, because the list of items will always take up as much space as possible before wrapping. Honestly I think this is a nice design improvement.
  3. Instead of moving the first item (last to overflow) into the overflow menu, we will truncate it with an ellipsis instead. This should almost never happen, as the first item would have to have a very long label.

Changelog

New

Changed

Removed

Rollout strategy

  • Patch release
  • Minor release
  • Major release; if selected, include a written rollout or migration plan
  • None; if selected, include a brief description as to why

Testing & Reviewing

Merge checklist

TylerJDev and others added 8 commits February 6, 2026 15:05
…e.module.css

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…e.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@changeset-bot
Copy link

changeset-bot bot commented Feb 6, 2026

🦋 Changeset detected

Latest commit: 0634340

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@primer/react Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions bot added the integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm label Feb 6, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Feb 6, 2026

👋 Hi, this pull request contains changes to the source code that github/github-ui depends on. If you are GitHub staff, test these changes with github/github-ui using the integration workflow. Or, apply the integration-tests: skipped manually label to skip these checks.

@iansan5653 iansan5653 changed the base branch from main to tylerjdev/overflow-underlinenav February 6, 2026 20:28
@github-actions github-actions bot requested a deployment to storybook-preview-7506 February 6, 2026 20:31 Abandoned
@github-actions github-actions bot temporarily deployed to storybook-preview-7506 February 6, 2026 20:40 Inactive
@iansan5653
Copy link
Contributor Author

BTW, I think the flex inspector in Chrome's devtools does a really nice job of showing what's going on here / where the tabs are going:

Screen.Recording.2026-02-06.at.5.00.33.PM.mov

@github-actions github-actions bot removed the update snapshots 🤖 Command that updates VRT snapshots on the pull request label Mar 3, 2026
@github-actions github-actions bot temporarily deployed to storybook-preview-7506 March 3, 2026 17:24 Inactive
@iansan5653
Copy link
Contributor Author

@copilot add a changeset for this pr to the .changeset folder

Copy link
Contributor

Copilot AI commented Mar 4, 2026

@iansan5653 I've opened a new pull request, #7622, to work on those changes. Once the pull request is ready, I'll request review from you.

…formance/code quality (#7622)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: iansan5653 <2294248+iansan5653@users.noreply.github.com>
Copy link
Contributor

@hectahertz hectahertz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really nice refactor! The shift from JS width measurements to CSS wrap-based overflow detection is a big win for CLS, and the createDescendantRegistry utility is clean and well-tested. Great code removal too.

Left some comments, nothing major.

})
// This is the case where the viewport is too narrow to show any list item with the more menu. In this case, we only show the dropdown
const onlyMenuVisible = responsiveProps.items.length === 0
const isOverflowing = menuItems.length > 0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This setState during render works (React allows it for the component's own state), but it will trigger an immediate re-render on first overflow detection. Totally fine in practice, just flagging since useEffect is the more common pattern.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, React will abandon this render and start a new one. It's uncommon but probably should be more common - React recommends it over using an effect

/* Progressive enhancement: Detect overflow using scroll-based animations.
The idiomatic way would be a scroll-state container query but browser support
is slightly better for animations. */
animation: detect-overflow linear;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice progressive enhancement! animation-timeline: scroll() isn't supported in Safari/Firefox yet, so it's good there's a JS fallback via data-has-overflow.

One thought: between first paint and the first IO callback, overflowing items will be clipped but the "More" button won't be visible yet. Is that flash acceptable, or should we show the button by default and hide it once we confirm nothing overflows?

Copy link
Contributor Author

@iansan5653 iansan5653 Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great question, I was wondering the same thing. The challenge is that we have to choose - on small screens do we delay showing the button, or on large screens do we show and then hide it when there's no overflow?

I think, given the choice, it's typically preferable to insert new UI after SSR vs hiding UI that you initially showed. Showing and then hiding something feels more flickery than showing it after a short delay. I also think large screens are probably more common.

To be clear, this flash would only happen in browsers that don't support scroll animation.

We could try and be more clever here by showing it by default on small screens based on some arbitrary breakpoint, trying to predict when there's most likely going to be an overflow. But I'm not sure it's worth the complexity and it could never be 100% reliable since we can't know what width would trigger the overflow until after we calculate the overflowed items.

hectahertz

This comment was marked as duplicate.

hectahertz

This comment was marked as duplicate.

Copy link
Contributor

@hectahertz hectahertz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really solid work on this. The CSS approach for overflow detection is a meaningful improvement over the old JS measurement loop, and the code is noticeably cleaner. Happy to see this land 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants